Rate Limit을 사용자와 IP 중 어디에 걸어야 할까

Rate Limit을 사용자와 IP 중 어디에 걸어야 할까

한눈에 보기

인증 전에는 IP와 장치 신호를, 인증 후에는 사용자와 API 키를 함께 사용한다. 엔드포인트 비용에 따라 서로 다른 버킷을 둔다.

목차

문제가 되는 상황

로그인 API에 IP당 분당 5회 제한을 걸었더니 회사 네트워크의 사용자 수십 명이 함께 차단될 수 있다. 반대로 사용자 ID만 기준으로 제한하면 인증 전 공격을 막을 수 없고, 공격자가 여러 계정을 만들거나 탈취한 API 키를 교체해 가며 비용이 큰 요청을 보낼 수 있다.

Rate Limit은 “IP와 사용자 중 하나를 고르는 설정”이 아니다. 어떤 자원을 보호하려는지, 요청 주체를 어느 시점부터 신뢰할 수 있는지, 여러 정상 사용자가 같은 네트워크를 공유하는지에 따라 여러 차원의 버킷을 겹쳐야 한다.

이 글의 예제에 관하여

제한 값, endpoint와 Redis key는 구조를 설명하기 위한 가상 예제다. 실제 서비스에 그대로 적용할 권장 숫자가 아니다.

먼저 보호하려는 자원을 정한다

Rate Limit의 목적을 나누면 key와 단위가 달라진다.

보호 대상 위협·비용 후보 기준
로그인 계정 비밀번호 대입 account + IP 대역
SMS 발송 직접 비용, 사용자 괴롭힘 전화번호 + 계정 + IP
검색 API CPU·DB 부하 사용자 또는 API key
데이터 export 큰 DB·스토리지 비용 사용자 + 조직
공개 API 전체 트래픽 폭주 IP + route group
파트너 계약 quota 사용량 과금 client ID 또는 API key

요청 수만 세는 것이 항상 맞지도 않다. export 한 번이 가벼운 조회 1,000번보다 비쌀 수 있다. endpoint별 cost unit을 두거나 동시 실행 수를 별도로 제한한다.

GET /search           cost 1
POST /reports/preview cost 10
POST /exports         cost 100 + concurrent limit 2

IP 기준의 장점과 한계

IP는 로그인 전에도 얻을 수 있어 공개 endpoint의 첫 방어선으로 유용하다. 하지만 안정적인 사용자 식별자는 아니다.

Reverse proxy 뒤에서는 애플리케이션이 보는 socket IP가 proxy일 수 있다. 어느 proxy가 어떤 forwarded header를 덮어쓰는지 명시하고, 신뢰한 hop에서 전달한 client IP만 사용한다.

function resolveClientIp(request: Request): string {
  if (!trustedProxyRanges.contains(request.socket.remoteAddress)) {
    return request.socket.remoteAddress;
  }

  return parseSanitizedForwardedFor(request.headers["x-forwarded-for"]);
}

단순히 첫 번째 header 문자열을 client IP로 믿으면 공격자가 제한 key를 임의로 바꿀 수 있다. proxy가 외부에서 들어온 forwarded header를 제거·재작성하도록 구성한다.

사용자, API 키, 조직 기준

인증 후에는 검증된 사용자 ID가 IP보다 업무 주체에 가깝다. 하지만 사용자 단위만 두면 한 조직에서 여러 계정으로 비싼 export를 동시에 실행할 수 있다. 다음 범위를 조합한다.

user:{userId}:search
organization:{orgId}:export
client:{clientId}:partner-api
credential:{apiKeyId}:write-api

API key 원문을 Redis key나 로그에 넣지 않고 서버 내부의 안정적인 key ID를 사용한다. 사용자가 여러 API key를 만들 수 있다면 key별 burst 제한과 계정 전체 quota를 함께 둔다.

로그인 실패 제한은 account 존재 여부를 외부에 드러내지 않도록 응답 메시지와 처리 시간을 일관되게 한다. 존재하지 않는 email도 정규화한 hash key로 제한해 공격자가 무제한으로 시도하지 못하게 할 수 있다.

여러 제한을 계층으로 조합한다

하나의 요청이 여러 버킷을 동시에 통과하도록 만들 수 있다.

flowchart LR
    R[요청] --> G{전역 과부하 제한}
    G -->|통과| I{IP burst 제한}
    I -->|통과| U{사용자 quota}
    U -->|통과| O{조직의 비싼 작업 제한}
    O -->|통과| A[API 처리]
    G -->|초과| X[429 또는 503]
    I -->|초과| X
    U -->|초과| X
    O -->|초과| X

예시 정책은 다음과 같다.

login:
  - 5 failures / 10 minutes / normalized account
  - 30 attempts / 10 minutes / client IP
search:
  - burst 20, refill 2 per second / user
  - 5000 cost units / hour / organization
export:
  - 3 starts / hour / organization
  - 2 concurrent jobs / organization

숫자는 트래픽 분포와 비용을 측정해 정한다. 평균 사용자보다 상위 정상 사용자의 burst를 보고 경고·soft limit·hard limit을 단계적으로 적용할 수도 있다.

Fixed Window, Sliding Window, Token Bucket

Fixed Window

user:42:202609021530처럼 시간 구간별 counter를 증가시킨다. 구현이 단순하지만 경계 직전과 직후에 각각 최대 요청을 보내면 짧은 시간에 두 배 burst가 가능하다.

Sliding Window

현재 시점에서 이전 N초 구간의 요청을 센다. 정확한 timestamp 목록을 저장하면 정밀하지만 메모리와 정리 비용이 크다. 이전·현재 fixed window를 가중 합산하는 근사 방식도 있다.

Token Bucket

버킷에 token이 일정 속도로 보충되고 요청이 cost만큼 소비한다. 용량만큼의 순간 burst를 허용하면서 장기 평균 속도를 제한하기 좋다.

type Bucket = {
  tokens: number;
  lastRefillMs: number;
};

function consume(bucket: Bucket, nowMs: number, policy: Policy, cost = 1) {
  const elapsedSeconds = Math.max(0, nowMs - bucket.lastRefillMs) / 1000;
  const refilled = Math.min(
    policy.capacity,
    bucket.tokens + elapsedSeconds * policy.refillPerSecond,
  );

  if (refilled < cost) {
    return { allowed: false, bucket: { tokens: refilled, lastRefillMs: nowMs } };
  }

  return {
    allowed: true,
    bucket: { tokens: refilled - cost, lastRefillMs: nowMs },
  };
}

이 코드는 단일 프로세스 원리 예시다. 실제 분산 서버에서는 읽기와 쓰기 사이의 경쟁을 막아야 한다.

분산 환경에서 원자적으로 차감하기

서버 인스턴스마다 메모리 counter를 가지면 client는 load balancer를 거치며 인스턴스 수만큼 제한을 더 사용할 수 있다. Redis 같은 공유 저장소를 사용할 수 있지만 다음 구현도 안전하지 않다.

// 좋지 않은 예: GET과 SET 사이에 다른 요청이 끼어든다.
const bucket = await redis.get(key);
const result = consume(bucket, now, policy);
await redis.set(key, result.bucket);

Lua script, transaction, 원자적인 rate-limit command로 refill 계산과 차감을 한 번에 수행한다. key TTL은 버킷이 가득 찬 뒤 더 이상 필요 없는 시점에 맞춰 메모리를 회수한다.

클러스터 환경에서는 key hash slot, Redis clock과 애플리케이션 clock, 장애 복제 지연을 고려한다. 모든 요청을 중앙 counter에 보내는 비용이 실제 보호 대상보다 더 커지지 않는지도 측정한다.

429 응답 계약

제한을 넘으면 일반적으로 429와 재시도 가능한 시점을 전달한다.

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/problem+json

{
  "status": 429,
  "code": "RATE_LIMIT_EXCEEDED",
  "retryAfterSeconds": 30
}

client는 Retry-After를 존중하고 jitter가 있는 backoff를 사용한다. 429를 받자마자 여러 번 재시도하면 제한 저장소와 API 부하를 더 키운다.

남은 quota와 reset 정보를 응답 header로 제공할 수 있지만 정확한 header 이름과 의미를 API 계약에 문서화한다. 보안 endpoint에서는 남은 시도 수를 너무 상세히 알려 주는 것이 공격자에게 도움이 되는지도 검토한다.

저장소 장애에서 fail-open과 fail-closed

Rate Limit 저장소가 timeout일 때 모든 요청을 허용하면 비용 폭주나 brute force 방어가 사라진다. 모든 요청을 막으면 limiter 장애가 서비스 전체 장애가 된다. endpoint 위험에 따라 다르게 정한다.

기능 장애 정책의 예 이유
공개 읽기 API 제한적으로 fail-open 가용성 우선, 다른 보호 존재
로그인·비밀번호 재설정 fail-closed 또는 로컬 보수 제한 계정 공격 위험
유료 SMS·대규모 export fail-closed 직접 비용·자원 위험
내부 health check limiter 우회 복구 관측 필요

fail-open에서도 인스턴스별 작은 emergency limiter와 circuit breaker를 둘 수 있다. 정책 결정과 발생 횟수를 지표로 남겨 limiter 장애가 조용히 보안 공백이 되지 않게 한다.

관측과 튜닝

제한이 잘 작동하는지 보려면 429 총량만으로 부족하다.

식별자는 로그에서 hash 또는 내부 ID로 보호하고, IP 보존 기간과 접근 권한을 개인정보 정책에 맞춘다. 정책 변경 전 shadow mode로 “차단했을 요청”만 기록해 영향을 추정할 수도 있다.

실전 점검 목록

Rate Limit 설계

  • 보호할 자원과 공격 시나리오가 먼저 정의되어 있는가?
  • 신뢰한 proxy에서만 client IP를 가져오는가?
  • 인증 전 IP·계정과 인증 후 사용자·조직 제한을 조합하는가?
  • 요청 비용과 동시성도 제한하는가?
  • 분산 counter 차감이 원자적인가?
  • 429와 Retry-After 계약이 client에 문서화되어 있는가?
  • 저장소 장애 정책이 endpoint 위험별로 정해져 있는가?
  • false positive를 발견할 지표와 안전한 예외 절차가 있는가?

인증 전에는 IP와 장치 신호를, 인증 후에는 사용자와 API 키를 함께 사용한다. 엔드포인트 비용에 따라 서로 다른 버킷을 둔다.

결론

Rate Limit은 IP나 사용자 하나를 고르는 것이 아니라, 보호할 자원에 맞춰 IP·계정·사용자·API key·조직·endpoint 비용 버킷을 계층적으로 조합하는 설계다. 신뢰한 proxy에서 IP를 해석하고 분산 저장소에서는 차감을 원자적으로 수행한다. 429 재시도 계약, 저장소 장애의 fail-open/closed 정책, false positive 관측까지 있어야 제한이 보안과 정상 사용성을 함께 지킬 수 있다.

관련 노트